# Send email from Next.js

> Send transactional email with Couryo from Next.js (App Router), with a Server Action, a Route Handler, React Email and a signed webhook.

Source: https://couryo.com/en/docs/nextjs

This guide uses the Next.js App Router and the [Node SDK](https://couryo.com/en/docs/sdks.md). Everything runs on the server: the API key never reaches the browser.

## 1. Install and set the key

```bash title="Terminal"
npm install @couryo/node
```

```ini title=".env.local"
COURYO_API_KEY=ck_test_...
COURYO_WEBHOOK_SECRET=whsec_...
```

Start with a `ck_test_` key: nothing is delivered and every email shows up in the dashboard ([test mode](https://couryo.com/en/docs/test-mode.md)). Switch to `ck_live_` once your [domain is verified](https://couryo.com/en/docs/domains.md).

Create the client once, on the server only. Lazy creation avoids an error in `next build` when the variable is not set in the build environment:

```ts title="lib/couryo.ts"
import "server-only";
import { Couryo } from "@couryo/node";

let client: Couryo | undefined;

export function couryo(): Couryo {
  client ??= new Couryo({ language: "en" }); // reads COURYO_API_KEY
  return client;
}
```

> The SDK uses Node modules (`node:crypto`). Keep the routes that send email on the default Node.js runtime, not Edge.

## 2. Server Action (contact form)

```ts title="app/contact/actions.ts"
"use server";

import { CouryoError } from "@couryo/node";
import { couryo } from "@/lib/couryo";

export type State = { ok: boolean; error?: string };

export async function sendContact(_prev: State, form: FormData): Promise<State> {
  const email = String(form.get("email") ?? "").trim();
  const message = String(form.get("message") ?? "").trim();
  if (!email || !message) return { ok: false, error: "Fill in your email and message." };

  try {
    await couryo().emails.send({
      from: "Example Site <contact@example.com>",
      to: "team@example.com",
      reply_to: email,
      subject: `Website contact: ${email}`,
      text: message,
    });
    return { ok: true };
  } catch (e) {
    // Couryo's message already says what to fix.
    if (e instanceof CouryoError) return { ok: false, error: e.message };
    throw e;
  }
}
```

```tsx title="app/contact/page.tsx"
"use client";

import { useActionState } from "react";
import { sendContact, type State } from "./actions";

const initial: State = { ok: false };

export default function Contact() {
  const [state, action, pending] = useActionState(sendContact, initial);
  return (
    <form action={action}>
      <input name="email" type="email" required placeholder="Your email" />
      <textarea name="message" required placeholder="Message" />
      <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>
      {state.ok && <p>Got your message.</p>}
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
```

## 3. Route Handler (email triggered by your backend)

When the same event can arrive twice (a repeated payment webhook, for example), pass an idempotency key that identifies the event. The second call gets the same response and nothing goes out twice.

```ts title="app/api/orders/[id]/confirmation/route.ts"
import { couryo } from "@/lib/couryo";

export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const order = await findOrder(id); // your function

  const email = await couryo().emails.send(
    {
      from: "Example Store <orders@example.com>",
      to: order.customer.email,
      subject: `Order ${order.number} confirmed`,
      html: `<p>Hi ${order.customer.name}, we got your order ${order.number}.</p>`,
      text: `Hi ${order.customer.name}, we got your order ${order.number}.`,
      tags: { kind: "order" },
      metadata: { order_id: order.id },
    },
    { idempotencyKey: `order-${order.id}-confirmation` },
  );

  return Response.json({ email_id: email.id, status: email.status });
}
```

The `202` response only comes once the email is queued. If something blocks the send (unverified domain, daily limit, suppressed recipient), the SDK throws `CouryoError` with a [stable code](https://couryo.com/en/docs/errors.md).

## 4. Templates with React Email

React Email works as is: render the component to HTML and text and send both.

```tsx title="app/api/welcome/route.tsx"
import { render } from "@react-email/render";
import Welcome from "@/emails/welcome"; // your React Email component
import { couryo } from "@/lib/couryo";

export async function POST(req: Request) {
  const { email, name } = await req.json();
  const html = await render(<Welcome name={name} />);
  const text = await render(<Welcome name={name} />, { plainText: true });

  await couryo().emails.send({ from: "Example <hello@example.com>", to: email, subject: `Welcome, ${name}!`, html, text });
  return new Response(null, { status: 204 });
}
```

Rather edit in the dashboard? Use a [saved template](https://couryo.com/en/docs/templates.md): `template: "welcome"` and `variables: { name }` instead of `html` and `text`.

## 5. Receive webhooks

Create the webhook in the dashboard (or through the API) pointing to `https://your-site.com/api/webhooks/couryo` and keep the `whsec_` secret. Read the body as text; do not parse it as JSON before verifying:

```ts title="app/api/webhooks/couryo/route.ts"
import { verifyWebhook, WebhookVerificationError } from "@couryo/node";

export async function POST(req: Request) {
  const body = await req.text();
  try {
    const event = verifyWebhook({ secret: process.env.COURYO_WEBHOOK_SECRET!, headers: req.headers, body });
    if (event.type === "bounced") {
      // event.data.recipient, event.data.smtp_code, event.data.explanation
    }
    return new Response(null, { status: 204 });
  } catch (e) {
    if (e instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
    throw e;
  }
}
```

Any `2xx` confirms the delivery; without one, Couryo retries for about 3 days. Details in [Webhooks](https://couryo.com/en/docs/webhooks.md).

## Without the SDK

On any runtime with `fetch` (Edge included), the API is a single call:

```ts title="TypeScript"
const res = await fetch("https://api.couryo.com/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.COURYO_API_KEY}`,
    "Content-Type": "application/json",
    "Accept-Language": "en",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ from: "Example <hello@example.com>", to: "ana@example.com", subject: "Hello", text: "Sent with Couryo." }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
```

## Common problems

| Symptom | What to do |
|---|---|
| `domain_not_verified` | the `from` domain is not verified in this project yet: see [Domains and DNS](https://couryo.com/en/docs/domains.md) |
| `sandbox_recipient` | the account is on level 0 and only sends to the account's own addresses until the first domain is verified |
| `Couryo: missing API key` during build | create the client inside a function (as in `lib/couryo.ts`) and set `COURYO_API_KEY` in production |
| Webhook signature always invalid | the body was read with `req.json()` first; use `req.text()` |
