Skip to content
couryo

Developer guideIntegrations

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.

View as Markdown

This guide uses the Next.js App Router and the Node SDK. Everything runs on the server: the API key never reaches the browser.

1. Install and set the key#

Terminal
npm install @couryo/node
.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). Switch to ck_live_ once your domain is verified.

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:

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)#

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;
  }
}
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.

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.

4. Templates with React Email#

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

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: 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:

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.

Without the SDK#

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

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
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()