Next.js integration: send transactional email

Updated

There is no Inboxili Next.js-specific package. You can use the official Node SDK `@inboxili/node` (see its page) or the plain HTTP code below.

In Next.js the rule is simple: the Inboxili API key lives in server-only code. Browsers call your route handler or server action, and that code calls Inboxili.

Environment

.env.local (never prefix with NEXT_PUBLIC_):

INBOXILI_API_KEY=ik_live_...

A server-only helper

lib/inboxili.ts:

import "server-only";

export class InboxiliError extends Error {
  constructor(public status: number, public code: string, message: string) {
    super(message);
  }
}

type Message = {
  to: string;
  from_email: string;
  from_name?: string;
  subject?: string;
  html_body?: string;
  text_body?: string;
  template_id?: string;
  template_data?: Record<string, string | number>;
};

export async function sendEmail(message: Message): Promise<{ status: string; message_id: string | null }> {
  const res = await fetch("https://api.inboxili.com/api/v1/transactional/send", {
    method: "POST",
    cache: "no-store",
    signal: AbortSignal.timeout(10_000),
    headers: {
      Authorization: `Bearer ${process.env.INBOXILI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(message),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new InboxiliError(res.status, body.error?.code ?? "http_error", body.error?.message ?? res.statusText);
  return body;
}

The server-only import makes the build fail if a client component ever imports this file. Install it with npm i server-only.

A route handler

app/api/contact/route.ts:

import { NextResponse } from "next/server";
import { InboxiliError, sendEmail } from "@/lib/inboxili";

const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

export async function POST(req: Request) {
  const { name, email, message } = await req.json();
  if (typeof email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || typeof message !== "string" || message.length > 5000) {
    return NextResponse.json({ error: "Invalid input" }, { status: 400 });
  }

  try {
    await sendEmail({
      to: "support@yourdomain.com",
      from_email: "website@yourdomain.com",
      from_name: "Website",
      subject: "New contact message",
      html_body: "<p><strong>{{name}}</strong> ({{email}}) wrote:</p><p>{{message}}</p>",
      template_data: { name: esc(String(name ?? "")), email: esc(email), message: esc(message) },
    });
    return NextResponse.json({ ok: true });
  } catch (err) {
    if (err instanceof InboxiliError && err.status === 429) {
      return NextResponse.json({ error: "Busy, try again shortly" }, { status: 503 });
    }
    console.error("inboxili send failed", err);
    return NextResponse.json({ error: "Could not send" }, { status: 502 });
  }
}

Everything that comes from the visitor is escaped before it goes into the HTML. This also stops a visitor from using your form to send arbitrary email: the recipient and sender are fixed in code.

Rate-limit public forms

A public contact form that triggers an API call is an abuse target, and each key allows 120 requests per minute. Add per-IP limiting (for example Upstash Ratelimit or middleware) in front of any route that sends mail.

Server action variant

"use server";
import { sendEmail } from "@/lib/inboxili";

export async function requestLoginCode(email: string) {
  const code = String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, "0");
  // store a hash of `code` with an expiry, then:
  await sendEmail({
    to: email,
    from_email: "security@yourdomain.com",
    subject: "Your code",
    html_body: "<p>Your code is <strong>{{code}}</strong></p>",
    text_body: `Your code is ${code}`,
    template_data: { code },
  });
}

See the OTP workflow for storing and checking the code.

Testing

Mock the helper in your unit tests with your test runner's module mocking, and keep a single manual end-to-end check against a real verified domain in staging.

Production notes

  • Add INBOXILI_API_KEY as a secret in your hosting provider, not in the repo.
  • Route handlers on serverless platforms have execution limits. Keep the call to one request and return quickly.
  • A timeout does not tell you whether the email went out. The API has no idempotency key.

Frequently asked questions

Can I call the API from a client component?
No. The API key would be exposed to every visitor. Call it from a route handler, server action or server component only.

Build with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related